qdanshitaのチューニング紹介


概要

nginx-luajit-websocket-udp、略してqdanshitaになった。

各機構の設定の関連性や、ログ、チューニングについて書く。

あとベンチマーク用にlocustスクリプトを用意したので、それについても書く。


リポジトリはここ。

https://github.com/sassembla/nginx-luajit-ws/tree/benchmark-with-netcore


機構構成

nginx + lua(micro websocket server per connection)、

nginx stream + go-udp-server(udp receiver/sender)

disque(upstream/downstream message queue) を組み合わせた機構。



特性

・1ユーザーにつき、消費するポートはユーザ接続tcp1つ + disque-nginx up/down tcp2 の3つ。

・queueを挟むため、負荷に強い。

・upstream(上流サーバ)を完全非同期に実装できる。この仕組みは、ゲームロジックをbackpressureの負荷を考えずに実行するのに役立つ。



接続とデータフロー

0.マッチング、接続に必要なパラメータを取得

1.クライアントとの間にudpでのデータ送付経路を確立

2. 0,1で取得したパラメータを使って、websocketでの接続を行う。


udp接続の手順はスキップすることができ、その場合downstreamでのudp送付は発生しなくなる。(要luaカスタマイズ)


接続後、udp、wsを介してdownstreamのデータがサーバからクライアントへと送付される。



nginx.conf、lua、go-udp-server、disqueのセッティング

nginx.conf

nginx.conf(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/DockerResources/nginx.conf)

workerごとの接続数などを設定する。


nginx streamで8080ポートで受け止めたudpを8081ポートで待つgo-udp-serverへとproxyしている。


urlに対してluaファイルのパスを指定、ルーティングを行う。

サンプルでは、次のような記述で、http://SOMEWHERE/sample_disque_client へと到達したリクエストを、sample_disque_client.luaスクリプトへと転送する。

# sample disque client route.

location /sample_disque_client {

    content_by_lua_file lua/sample_disque_client.lua;

}




lua

/luaフォルダ以下に入っている。

nginx.confのルーティングから呼ばれ、リクエスト時に実行される。

セッティング、リクエストヘッダ値の読み出し/分岐、そのパラメータを使った認証機構を入れる箇所がある。

また、upstreamが リクエストurl末尾path + _context というqueue名でdisqueからデータを引き出せるようになっている。


sample_disque_client.lua(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/DockerResources/lua/sample_disque_client.lua)

-- get identity of game from url. e.g. http://somewhere/game_key -> game_key_context.

UPSTREAM_IDENTIFIER = string.gsub(ngx.var.uri, "/", "") .. "_context"


-- message type definitions.

STATE_CONNECT           = 1

STATE_STRING_MESSAGE    = 2

STATE_BINARY_MESSAGE    = 3

STATE_DISCONNECT_INTENT = 4

STATE_DISCONNECT_ACCIDT = 5

STATE_DISCONNECT_DISQUE_ACKFAILED = 6

STATE_DISCONNECT_DISQUE_ACCIDT_SENDFAILED = 7



---- SETTINGS ----


-- upstream/downstream queue.

DISQUE_IP = "127.0.0.1"

DISQUE_PORT = 7711


-- CONNECTION_ID is nginx's request id. that len is 32. guidv4 length is 36, add four "0". 

-- overwritten by token.

CONNECTION_ID = ngx.var.request_id .. "0000"


-- go unix domain socket path.

UNIX_DOMAIN_SOCKET_PATH = "unix:/tmp/go-udp-server"


-- max size of downstream message.

DOWNSTREAM_MAX_PAYLOAD_LEN = 1024



---- REQUEST HEADER PARAMS ----



local token = ngx.req.get_headers()["token"]

if not token then

    ngx.log(ngx.ERR, "no token.")

    return

end



local udp_port = ngx.req.get_headers()["param"]

if not udp_port then

    ngx.log(ngx.ERR, "no param.")

    return

end


---- POINT BEFORE CONNECT ----


-- redis example.

-- このままだと通信単位でredisアクセスが発生しちゃうので、このブロック内で、なんらかのtokenチェックをやるとかするとなお良い。このサーバにくるはずなら~とかそういう要素で。

if false then

    local redis = require "redis.redis"

    local redisConn = redis:new()

    local ok, err = redisConn:connect("127.0.0.1", 6379)


    if not ok then

        ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " failed to generate redis client. err:", err)

        return

    end


    -- トークンをキーにして取得

    local res, err = redisConn:get(token)

    

    -- キーがkvsになかったら認証失敗として終了

    if not res then

        -- no key found.

        ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " failed to authenticate. no token found in kvs.")


        -- 切断

        redisConn:close()

        ngx.exit(200)

        return

    elseif res == ngx.null then

        -- no value found.

        ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " failed to authenticate. token is nil.")


        -- 切断

        redisConn:close()

        ngx.exit(200)

        return

    end


    -- delete got key.

    local ok, err = redisConn:del(token)


    -- 切断

    redisConn:close()


    -- 変数にセット、パラメータとして渡す。

    user_data = res

else

    user_data = token

    CONNECTION_ID = token

end


-- ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " user_data:", user_data)




---- CONNECT ----

...


go-udp-server

/goフォルダ以下に入っている。


main.go(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/DockerResources/go/main.go)

をコンパイルして起動しておく。

デフォルトでは8081ポートでudp接続を受け付け、tmp/go-udp-serverという名前のunix domain socketを読み込む。

nginx stream機構の下で動くのを基礎としていて、nginxは8080/udpでudpを受け付け、8081 go-udp-serverへとデータをproxyする。


起動時にオプションを渡すことで、デフォルト設定を上書きすることができる。centosなどを使う際は適当に指定するといいと思う。


--portオプションでudpを待ち受けるポートを指定、

--domainオプションで、unix domain socketのパスを指定する。


負荷が高くなるとlua側で shared connection is busy while proxying connection ログが出る。ただ、これが出る状態がすでにコア数に対してnginxのworkerがサチっている



disque

disque-serverを別途起動しておく。

起動時に --maxclients 100000 とかつけておくと、disqueのデフォルト値の10000以上の接続が可能になる。


luaから接続 -> disque -> upstreamへとデータを送り、

upstream -> disque -> lua -> クライアントへとデータを送る中継点に使われる。


Upstream

要はServer。


サンプルとして、約60fpsでクライアントへとechoを行うdotnet coreの機構を用意してある。

https://github.com/sassembla/nginx-luajit-ws/tree/benchmark-with-netcore/DockerResources/csharp



ログ

接続、切断、エラーなどの基本的な情報は、すべてnginxのerror.logに出るようになっている。

デフォルトでは切断時、エラー時のみログを出力している。


出力もとはluaなので、編集したい場合はそちらを。



チューニング

nginx error log

-> max number of clients reached

-> disquemax connection設定を上回る接続がdisqueに来た。 デフォルトは10000、disqueを--maxclients 100000とかつけて起動すればOK。


-> too many なんちゃら、worker なんちゃら

-> nginx.confにworker_rlimit_nofileやworker_connectionsの設定があるのでいじると良い。

Nginxのパフォーマンスを極限にするための考察

https://qiita.com/iwai/items/1e29adbdd269380167d2


-> shared connection is busy while proxying connection

-> luaからgo-udp-serverへとudpデータを送付する際に、nginx luaのソケットIOの限界を超えていると発生する。



クライアント側エラー

-> connection refused, reset by peer

-> サーバのtcp/ipのソケット数限界に達している可能性が高い。

ubuntuだったら echo 1024 65535 > /proc/sys/net/ipv4/ip_local_port_range とかでパラメータ変えればいい。


disquuun部分の負荷が高い

-> disquuunの初期化パラメータのコネクション数を増やすことで負荷が下がるケースがある。

60fps 10msg/sec up/down 5000接続以上で動く場合は、30接続くらいでいい予感がする。それ以上あげてもスペックが変わらない。


また、各ユーザー向けのデータを送付時に各ユーザー単位でまとめると負荷が下がる。

理想は1fあたり各ユーザーに対して1通の送付にできるといい。

go-udp-serverの負荷が高い

-> 20~30%くらいはよくある感じ。

メッセージ送付の数にそのまま関連するので、downstreamのメッセージ数を見直すと解消しやすい。


各パーツの有無による負荷変動について

lua <-> disqueのメッセージのやり取りは、nginxのworkerへの負荷がわずかにある。


これはworker数が多ければ多いほど各workerの負荷が減るので、スペックが伸びる。

4コアのマシンで 60fps 10msg/sec up/down を実施させたところ、6000接続を超えたあたりからクライアントへのデータ送付がわずかに遅れるケースが散見された。



nginx workerに負荷がかかるとどうなるか

・クライアントへと届けるdownstreamが遅延する。ここが遅延するのは、各workerの負荷が40%とかを超えてから。worker数が多ければ避けられる。

worker数を増やす、downstreamへのメッセージをまとめる、などで負荷が軽減できる。



disqueに負荷がかかるとどうなるか

・あまり問題が起こらない。メッセージの到達遅延につながったことはないっぽい。

もし負荷が気になる場合、luaを改変してworkerごとに異なるdisqueにつながるようにするといいと思う。



upstreamに負荷がかかるとどうなるか

・全体のメッセージの流れが遅延する。

upstreamのメッセージの吸い出しが遅くなり、

downstreamのメッセージの送付が遅くなる。


メッセージの消化不良が溜まっていく以外に影響はでない。

ただし、メッセージの消化不良が起こると、disqueがどんどんメモリを食っていくことになるので、

その辺に関して注意が必要。メッセージサイズが小さい + メッセージをまとめて扱うような工夫が効果が高い。



go-udp-serverに負荷がかかるとどうなるか

・あまり問題が起こらない。メッセージの到達遅延につながったことはないっぽい。

負荷が気になる場合、新規にポートを指定してgo-udp-serverを追加し、nginx.confへとポート情報を加える。

すると自動的に複数のgo-udp-serverへとデータが流れるため、負荷が分散できる。



locust

ベンチマークはlocustを使って行なっている。

使用しているlocust fileは、https://github.com/sassembla/nginx-luajit-ws/tree/benchmark-with-netcore/locust に動作するものを置いてある。


サンプルはDockerコンテナで動作するものになっていて、

ubuntuなどに放り込んで、rebuild.shを実行すれば起動、その後ポート8089にアクセスすれば接続と負荷掛けを実行できる。



デフォルトで設定されている負荷は、

・1locustごとに1接続

・1locustにつき秒間10件程度のデータをサーバに送り、サーバから秒間10件程度のtcp + udpでのエコーを返す

・接続が確立されたり、切断されるとlocustのコンソールにメッセージやエラーが表示される

・データを受け取っていない場合のwarningとして、1秒以上データを受け取っていないクライアントにwarningが出る



変更すべき設定は、接続先のサーバのIPとport。


locusts.py(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/locust/test/locusts.py)

    server_ip = "150.95.211.59"

    # server_ip = "127.0.0.1"


    server_port = 8080

    message_per_sec = 100.0 / 1000.0

    server_path = "sample_disque_client"


server_ip、

server_port、

message_per_sec:メッセージを秒間何件クライアントから送付 -> サーバから返送するか

この記述だと100.0 / 1000.0 = 0.1で、秒間10件送付する。名前間違えたな。。


server_path: url末尾につけられるパス。server_ip:server_port//server_path になる。